1"use client";
2
3
23import { useEffect, useMemo, useRef, useState } from "react";
24import { useParams, useRouter } from "next/navigation";
25import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
26import { ArrowLeft, Check, Copy, Loader2, Play, Save, Trash2 } from "lucide-react";
27import { toast } from "@/stores/ui-store";
28import { apiFetch, ApiError } from "@/lib/api";
29import { useWorkspace } from "@/hooks/use-workspace";
30import { SettingsShell, Section } from "@/components/settings/settings-shell";
31import {
32 AUTOMATION_STATUS_TRANSITIONS,
33 conditionDraftFromWire,
34 conditionPayload,
35 conditionSummary,
36 emptyCondition,
37 statusCls,
38 triggerDraftFromWire,
39 triggerPayload,
40 actionDraftFromWire,
41 actionPayload,
42 runStatusLabel,
43 type ActionDraft,
44 type AutomationDetail,
45 type AutomationStatus,
46 type ConditionDraft,
47 type TriggerDraft
48} from "@/components/automations/lib";
49import { TriggerEditor } from "@/components/automations/trigger-editor";
50import { ConditionEditor, validateConditionJson } from "@/components/automations/condition-editor";
51import { ActionEditor } from "@/components/automations/action-editor";
52import { RunHistory } from "@/components/automations/run-history";
53
54interface PutTriggersResult {
55 automationId: string;
56 triggers: { type: string; config: Record<string, unknown> }[];
57 webhooks: { webhookId: string; secret: string; url: string }[];
58}
59
60export default function AutomationDetailPage() {
61 const params = useParams<{ workspace: string; id: string }>();
62 const slugOrId = params?.workspace ?? "";
63 const automationId = params?.id ?? "";
64 const router = useRouter();
65 const qc = useQueryClient();
66 const { data: ws, isLoading } = useWorkspace(slugOrId);
67
68 const detail = useQuery({
69 queryKey: ["automations", automationId],
70 queryFn: async (): Promise<AutomationDetail> => apiFetch<AutomationDetail>(`/automations/${automationId}`),
71 enabled: !!automationId
72 });
73 const a = detail.data;
74
75
76 const [name, setName] = useState("");
77 const [description, setDescription] = useState("");
78 const [triggers, setTriggers] = useState<TriggerDraft[]>([]);
79 const [condition, setCondition] = useState<ConditionDraft>(emptyCondition());
80 const [actions, setActions] = useState<ActionDraft[]>([]);
81
82 const [baseline, setBaseline] = useState<string | null>(null);
83 const [sectionError, setSectionError] = useState<string | null>(null);
84 const [secret, setSecret] = useState<{ url: string; secret: string } | null>(null);
85 const [copied, setCopied] = useState(false);
86
87 const initRef = useRef<string | null>(null);
88
89
90
91
92 useEffect(() => {
93 if (!a) return;
94 if (initRef.current === a.id) return;
95 initRef.current = a.id;
96 const t = a.triggers.map((x) => triggerDraftFromWire(x));
97 const c = conditionDraftFromWire(a.condition);
98 const acts = a.actions.map((x) => actionDraftFromWire(x));
99 setName(a.name);
100 setDescription(a.description ?? "");
101 setTriggers(t);
102 setCondition(c);
103 setActions(acts);
104 setSectionError(null);
105 setSecret(null);
106
107
108 setBaseline(localSignature(a.name, a.description ?? "", t, c, acts));
109 }, [a]);
110
111 const dirty = useMemo(
112 () => (baseline !== null ? localSignature(name, description, triggers, condition, actions) !== baseline : false),
113 [baseline, name, description, triggers, condition, actions]
114 );
115
116 const metaKey = ["automations", automationId] as const;
117
118
119 const patchMeta = useMutation({
120 mutationFn: () =>
121 apiFetch(`/automations/${automationId}`, {
122 method: "PATCH",
123 body: { name: name.trim(), description: description.trim() || null }
124 })
125 });
126
127 const putTriggers = useMutation({
128 mutationFn: () =>
129 apiFetch<PutTriggersResult>(`/automations/${automationId}/triggers`, {
130 method: "PUT",
131 body: { triggers: triggers.map((t) => triggerPayload(t)) }
132 }),
133 onSuccess: (res) => {
134 if (res.webhooks.length > 0) {
135 const wh = res.webhooks[0]!;
136 setSecret({ url: wh.url, secret: wh.secret });
137 }
138
139 setTriggers(res.triggers.map((t) => triggerDraftFromWire(t)));
140 }
141 });
142
143 const putConditions = useMutation({
144 mutationFn: () => {
145 if (condition.hasCondition && condition.json.trim() && validateConditionJson(condition.json)) {
146 throw new ApiError("INVALID_CONDITION", 422, "Condition JSON does not match the condition schema.");
147 }
148 let body: { condition: unknown };
149 try {
150 body = conditionPayload(condition);
151 } catch (e) {
152 throw new ApiError("INVALID_CONDITION", 422, `Invalid condition JSON: ${(e as Error).message}`);
153 }
154 return apiFetch(`/automations/${automationId}/conditions`, { method: "PUT", body });
155 }
156 });
157
158 const putActions = useMutation({
159 mutationFn: () =>
160 apiFetch<AutomationDetail>(`/automations/${automationId}/actions`, {
161 method: "PUT",
162 body: { actions: actions.map((x) => actionPayload(x)) }
163 }),
164 onSuccess: (res) => {
165
166 setActions(res.actions.map((x) => actionDraftFromWire(x)));
167 }
168 });
169
170 const save = () => {
171 setSectionError(null);
172 void (async () => {
173 try {
174
175
176 const [trigRes, , actRes] = await Promise.all([
177 triggers.length > 0 ? putTriggers.mutateAsync() : Promise.resolve(null as PutTriggersResult | null),
178 putConditions.mutateAsync(),
179 actions.length > 0 ? putActions.mutateAsync() : Promise.resolve(null as AutomationDetail | null),
180 patchMeta.mutateAsync()
181 ]);
182 const nextTriggers = trigRes ? trigRes.triggers.map((t) => triggerDraftFromWire(t)) : triggers;
183 const nextActions = actRes ? actRes.actions.map((x) => actionDraftFromWire(x)) : actions;
184
185
186 setBaseline(localSignature(name, description, nextTriggers, condition, nextActions));
187 if (trigRes) setTriggers(nextTriggers);
188 if (actRes) setActions(nextActions);
189 toast("success", "Automation saved");
190 qc.invalidateQueries({ queryKey: metaKey });
191 } catch (err) {
192 const msg = err instanceof Error ? err.message : "Save failed";
193 setSectionError(msg);
194 toast("error", "Save failed", msg);
195 }
196 })();
197 };
198
199 const setStatus = useMutation({
200 mutationFn: (status: string) =>
201 apiFetch<AutomationDetail>(`/automations/${automationId}/status`, { method: "POST", body: { status } }),
202 onSuccess: (res) => {
203 qc.setQueryData(metaKey, res);
204 toast("success", `Automation ${res.status.toLowerCase()}`);
205 },
206 onError: (err) => {
207 if (err instanceof ApiError && err.status === 409) toast("error", "Cannot change status", err.message);
208 else toast("error", "Status change failed", err instanceof Error ? err.message : undefined);
209 }
210 });
211
212 const runNow = useMutation({
213 mutationFn: () => apiFetch<{ runId: string }>(`/automations/${automationId}/run`, { method: "POST", body: {} }),
214 onSuccess: (res) => {
215 toast("success", "Run triggered", res.runId ? `run ${res.runId.slice(0, 8)}…` : undefined);
216 qc.invalidateQueries({ queryKey: ["automations", automationId, "runs"] });
217 qc.invalidateQueries({ queryKey: metaKey });
218 },
219 onError: (err) => toast("error", "Could not run", err instanceof Error ? err.message : undefined)
220 });
221
222 const regenWebhook = useMutation({
223 mutationFn: () =>
224 apiFetch<{ webhookId: string; secret: string; url: string }>(`/automations/${automationId}/webhook/regenerate`, {
225 method: "POST"
226 }),
227 onSuccess: (res) => {
228 setSecret({ url: res.url, secret: res.secret });
229 toast("success", "Webhook secret regenerated", "Copy the new secret now — it won't be shown again.");
230 },
231 onError: (err) => toast("error", "Regenerate failed", err instanceof Error ? err.message : undefined)
232 });
233
234 const remove = useMutation({
235 mutationFn: () => apiFetch(`/automations/${automationId}`, { method: "DELETE" }),
236 onSuccess: () => {
237 toast("success", "Automation deleted (archived)");
238 qc.invalidateQueries({ queryKey: ["workspaces", ws?.id ?? "", "automations"] });
239 router.push(`/w/${slugOrId}/settings/automations`);
240 },
241 onError: (err) => toast("error", "Delete failed", err instanceof Error ? err.message : undefined)
242 });
243
244
245 const status = (a?.status ?? "DRAFT") as AutomationStatus;
246 const transitions = AUTOMATION_STATUS_TRANSITIONS[status] ?? [];
247 const saving = patchMeta.isPending || putTriggers.isPending || putConditions.isPending || putActions.isPending;
248
249 return (
250 <SettingsShell title="Automation" description="Configure triggers, condition and actions." active="automations">
251 {isLoading || !ws ? (
252 <p className="text-sm text-muted-foreground">Loading…</p>
253 ) : detail.isLoading || !a ? (
254 <p className="text-sm text-muted-foreground">Loading…</p>
255 ) : detail.isError ? (
256 <p className="text-sm text-destructive">Could not load automation: {(detail.error as Error).message}</p>
257 ) : (
258 <div className="space-y-5">
259 {}
260 <div className="flex flex-wrap items-center gap-2">
261 <button
262 type="button"
263 className="inline-flex items-center gap-1 rounded-md border px-2 py-1 text-sm text-muted-foreground hover:text-foreground"
264 onClick={() => router.push(`/w/${slugOrId}/settings/automations`)}
265 >
266 <ArrowLeft className="size-4" /> Back
267 </button>
268 <span className={`inline-block rounded-full px-2 py-0.5 text-[11px] font-medium ${statusCls(a.status)}`}>{a.status}</span>
269 <span className="flex-1" />
270 {transitions.map((t) => (
271 <button
272 key={t}
273 type="button"
274 className="rounded-md border px-2.5 py-1 text-sm hover:bg-muted disabled:opacity-50"
275 disabled={setStatus.isPending}
276 onClick={() => setStatus.mutate(t)}
277 >
278 {t === "ACTIVE" ? "Activate" : t === "PAUSED" ? "Deactivate" : "Archive"}
279 </button>
280 ))}
281 <button
282 type="button"
283 data-testid="automation-run-now"
284 className="inline-flex items-center gap-1 rounded-md border border-primary px-2.5 py-1 text-sm font-medium text-primary hover:bg-primary/5 disabled:opacity-50"
285 disabled={runNow.isPending}
286 onClick={() => runNow.mutate()}
287 >
288 {runNow.isPending ? <Loader2 className="size-4 animate-spin" /> : <Play className="size-4" />}
289 Run now
290 </button>
291 </div>
292
293 {}
294 <div className="space-y-3 rounded-lg border p-4">
295 <input
296 data-testid="automation-name-input"
297 className="w-full rounded-md border border-input bg-background px-2.5 py-1.5 text-lg font-semibold outline-none focus-visible:ring-1 focus-visible:ring-ring"
298 value={name}
299 maxLength={100}
300 onChange={(e) => setName(e.target.value)}
301 aria-label="Automation name"
302 />
303 <textarea
304 className="w-full rounded-md border border-input bg-background px-2.5 py-1.5 text-sm outline-none focus-visible:ring-1 focus-visible:ring-ring"
305 placeholder="Description (optional)"
306 value={description}
307 maxLength={500}
308 onChange={(e) => setDescription(e.target.value)}
309 aria-label="Automation description"
310 />
311 </div>
312
313 {}
314 {secret && (
315 <div className="rounded-md border border-primary/40 bg-primary/5 p-3">
316 <p className="mb-1.5 text-xs font-medium text-primary">Copy this webhook secret now — it won't be shown again.</p>
317 <div className="flex items-center gap-1.5">
318 <code className="min-w-0 flex-1 truncate rounded bg-background px-2 py-1.5 text-xs">{secret.secret}</code>
319 <button
320 type="button"
321 className="inline-flex items-center gap-1 rounded-md border px-2 py-1 text-xs"
322 onClick={async () => {
323 try {
324 await navigator.clipboard.writeText(secret.secret);
325 setCopied(true);
326 setTimeout(() => setCopied(false), 2000);
327 } catch {
328
329 }
330 }}
331 >
332 {copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
333 {copied ? "Copied" : "Copy"}
334 </button>
335 </div>
336 <p className="mt-1.5 truncate text-xs text-muted-foreground">POST {secret.url}</p>
337 </div>
338 )}
339
340 {}
341 <Section title="Triggers" description="1–3 triggers (OR — the automation fires when any matches).">
342 <TriggerEditor
343 triggers={triggers}
344 onChange={setTriggers}
345 workspaceId={ws.id}
346 onRegenerateWebhook={() => regenWebhook.mutate()}
347 regenerating={regenWebhook.isPending}
348 />
349 </Section>
350
351 {}
352 <Section title="Condition" description={conditionSummary(condition)}>
353 <ConditionEditor draft={condition} onChange={setCondition} />
354 </Section>
355
356 {}
357 <Section title="Actions" description="Ordered (top → bottom), 1–50.">
358 <ActionEditor actions={actions} onChange={setActions} />
359 </Section>
360
361 {}
362 <div className="flex items-center gap-3">
363 <button
364 type="button"
365 data-testid="automation-save"
366 className="inline-flex items-center gap-1.5 rounded-md bg-primary px-4 py-2 text-sm font-medium text-white hover:opacity-90 disabled:opacity-50"
367 disabled={!dirty || saving}
368 onClick={save}
369 >
370 <Save className="size-4" /> Save changes
371 </button>
372 {dirty && <span className="text-xs text-muted-foreground">Unsaved changes</span>}
373 <span className="flex-1" />
374 <button
375 type="button"
376 className="inline-flex items-center gap-1 rounded-md border border-destructive/40 px-3 py-2 text-sm text-destructive hover:bg-destructive/10"
377 onClick={() => {
378 if (window.confirm(`Delete “${a.name}”? This archives it (runs are kept).`)) remove.mutate();
379 }}
380 >
381 <Trash2 className="size-4" /> Delete
382 </button>
383 </div>
384 {sectionError && (
385 <p className="rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs text-destructive">{sectionError}</p>
386 )}
387
388 {}
389 <Section title="Run history" description="Latest 50 runs. Click a run to expand its logs.">
390 <div data-testid={`automation-runs-${automationId}`}>
391 <RunHistory automationId={automationId} />
392 </div>
393 <p className="mt-2 text-[11px] text-muted-foreground">Latest: {a.latestRun ? runStatusLabel(a.latestRun.status) : "never run"}</p>
394 </Section>
395 </div>
396 )}
397 </SettingsShell>
398 );
399}
400
401
402
403
404function localSignature(
405 name: string,
406 description: string,
407 triggers: TriggerDraft[],
408 condition: ConditionDraft,
409 actions: ActionDraft[]
410): string {
411 let cond: unknown;
412 try {
413 cond = conditionPayload(condition).condition;
414 } catch {
415 cond = condition.json;
416 }
417 return [
418 name.trim(),
419 description.trim(),
420 JSON.stringify(triggers.map((t) => triggerPayload(t))),
421 JSON.stringify(cond),
422 JSON.stringify(actions.map((x) => actionPayload(x)))
423 ].join("\u0001");
424}
425